// Copyright 2010 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.


/**
 * @fileoverview The layer manager handles the retrieval of layers and their
 * content and manages the injection of that content into webpages.  The
 * manager also acts like a buffer and does not allow direct access to layer
 * content.  The layer is based on the Chrome Extension and extends its
 * functionality for injectable content.
 *
 * All functions that make use of the ids parameter can take a single string
 * id or an array of string ids.  Internally, the input will be validated and
 * converted to an array of strings.  If a function deviates from this usage
 * then the deviation will be specified by the function.  It is also important
 * to note that layer ids are also their content locations, so the manager will
 * use the id during content retrieval as the location.
 *
 * All functions that take an optional callback are asynchronous due to the
 * possible retrieval of data from remote locations.  If the function completes
 * immediately as in an error or nothing to retrieve then the function still
 * follows the asynchonous pathway; return and fire the given callback.
 *
 * Due to the asynchronous nature of some functions, their affect on the state
 * of the manager mandates a locking mechanism.  Each time a function that
 * affects the state of the manager is called it "locks" the manager until it
 * completes.  This means an further calls except to getStatus or isCached will
 * fail.
 *
 * Public functions:
 *   shellExtension.LayerManager() - Create a new LayerManager instance.
 *
 * Public data for shellExtension.LayerManager instance:
 *   onLogUpdated - This event is fired each time the LayerManager's log adds a
 *       new entry.
 *   onInjectionComplete - This event is fired each time content injection into
 *       a page completes, but does not fire if injection fails.  This event
 *       may not fire immediately as ondemand scripts will be retrieved before
 *       injection if not already loaded.
 *
 * Public prototype functions for shellExtension.LayerManager:
 *   add(ids, opt_callback) - Adds new layer(s) to be managed.
 *   changeOndemand(ids, state, opt_callback) - Change the ondemand state for
 *       the given layers.
 *   changeState(ids, state, opt_callback) - Change the given layers' state.
 *   clear() - Clear the manager of all layer and script data.  This does not
 *       affect the log and other signals.
 *   getLayerDetails(opt_id) - Returns details about all layers or just for the
 *       layer specified by the id.  Nothing happens if the given id is not
 *       managed.
 *   getStatus() - Returns the status of the layer manager.  This does not
 *       incorporate the layer and script status; use getLayerDetails for that
 *       information.
 *   inject(ids, tabId, url, opt_callback) - Allows users to cause an injection
 *       into the specified tab to occur following the normal rules for
 *       injection.
 *   isCached() - Returns whether or not there is layer information cached.
 *   remove(ids) - Remove the layer(s) from the set of managed layers.
 *   resetFromCache(opt_callback) - Clears the manager, load cached layer data
 *       if available or [] if not, reload the discovered layers, and set
 *       each layer's state to cached values.
 *   resetLayer(ids, opt_callback) - Causes the specified layer(s) to be
 *       reloaded.  This means the layer content and its scripts that are
 *       loaded immediately will be retrieved.
 *
 * @author jasonstredwick@google.com (Jason Stredwick)
 */


goog.provide('shellExtension.LayerManager');

goog.require('bite.common.log.Simple');
goog.require('bite.common.signal.Simple');
goog.require('shellExtension.Layer');
goog.require('shellExtension.data');
goog.require('shellExtension.manifest');
goog.require('shellExtension.reload');
goog.require('shellExtension.status');



/**
 * The LayerManager class stores all the data related to layers.
 * @constructor
 */
shellExtension.LayerManager = function() {
  /**
   * Records listeners for completion of content script injections.
   * @type {!bite.common.signal.Simple}
   * @private
   */
  this.injectionSignal_ = new bite.common.signal.Simple();

  /**
   * This is a collection of all the layers currently being managed by id.
   * @type {!Object.<string, shellExtension.Layer>}
   * @private
   */
  this.layers_ = {};

  /**
   * This is an array of layers for the purpose of ensuring the order in which
   * a layer is processed in some functions is the same as its load order.
   * @type {!Array.<shellExtension.Layer>}
   * @private
   */
  this.layersInOrder_ = [];

  /**
   * Maintain a log of events that occur within the manager.
   * @type {!bite.common.log.Simple}
   * @private
   */
  this.log_ = new bite.common.log.Simple();

  /**
   * Contains the current status of the manager. See
   * shellExtension.status.Value.  The LayerManager can have status of
   * READY, LOADING, and BAD_OBJECT.
   * @type {shellExtension.status.Value}
   * @private
   */
  this.status_ = shellExtension.status.Value.READY;

  // Create access to log events.
  var log = this.log_;

  /**
   * Interface for the log's signal.  Available for direct access.
   * @type {!Object.<string, function(!bite.common.log.Simple.Entry)>}
   */
  this.onLogUpdated = {
    'addListener': function(callback) { log.addListener(callback); },
    'hasListener': function(callback) { log.hasListener(callback); },
    'removeListener': function(callback) { log.removeListener(callback); }
  };

  // Create access to injection events.
  var sig = this.injectionSignal_;

  /**
   * Interface for the injection signal.  Available for direct access.
   * @type {!Object.<string, function()>}
   */
  this.onInjectionComplete = {
    'addListener': function(callback) {
      sig.addListener(/** @type {function(...[*])} */ (callback));
    },
    'hasListener': function(callback) {
      sig.hasListener(/** @type {function(...[*])} */ (callback));
    },
    'removeListener': function(callback) {
      sig.removeListener(/** @type {function(...[*])} */ (callback));
    }
  };

  // Attach handler to onUpdated event for tabs to inject content scripts.
  // Ignore if not run in the extension environment.
  if (typeof(chrome) == 'undefined' || !(chrome.tabs)) {
    throw 'ERROR (shellExtension.LayerManager): Chrome Extension tab API is ' +
          'not defined.  This code expects to run in the Chrome Extensions ' +
          'environment.';
  }
  chrome.tabs.onUpdated.addListener(goog.bind(this.handleTabUpdated_, this));
};


/**
 * Identifiers for the different type of filters to be applied to ids given as
 * inputs to the various manager functions.
 * @enum {number}
 * @private
 */
shellExtension.LayerManager.Filter_ = {
  ALL: 0,
  MANAGED: 1,
  NEW: 2,
  NONE: 3
};


/**
 * The key used for caching layer information; primarily layer settings' state.
 * @type {string}
 * @private
 */
shellExtension.LayerManager.CACHE_KEY_ = 'shellExtension.layerList';


/**
 * Performs a given action and handles setup and tear down around that action.
 * This is meant to reduce the overhead of setup, tear down, and validation
 * for public facing function, which would have duplicated this code each time.
 * @param {!Array.<string>|string} ids The id or set of ids to be added.
 * @param {string} functionId The calling function's name + namespace.
 * @param {shellExtension.LayerManager.Filter_} filter The filter to use during
 *     setup that applies to the given ids.
 * @param {function(!Array.<string>, function())} action The function
 *     representing the action taken by calling function.  It takes the array
 *     ids and completeHandler created within this function.
 * @param {function(boolean)=} opt_callback The callback is called when the
 *     process terminates and passes along a boolean value of whether or not
 *     the process completed.
 * @param {function()=} opt_onComplete The callback allows extra functionality
 *     to be used when the handler for the process is fired at completion.
 * @private
 */
shellExtension.LayerManager.prototype.action_ = function(ids,
                                                         functionId,
                                                         filter,
                                                         action,
                                                         opt_callback,
                                                         opt_onComplete) {
  // Create completion handler.
  var completeHandler = (function(manager, callback) {
    return function() {
      // Execute extra functionality when the completeHandler is fired.
      opt_onComplete && opt_onComplete();

      manager.log_.add('COMPLETE (' + functionId + ').');
      manager.tearDown_();
      callback && callback(true);
    };
  })(this, opt_callback);

  // Setup for action and validate external inputs.
  try {
    var isType = shellExtension.utils.isType;
    if (opt_callback && !isType(opt_callback, 'function')) {
      throw 'Invalid callback.';
    }

    if (filter == shellExtension.LayerManager.Filter_.NONE) {
      this.setup_([], filter);
    } else {
      ids = this.setup_(ids, filter);
    }
    this.log_.add('START (' + functionId + ').');
    // Execute calling function's action.
    action(/** @type {!Array.<string>} */ (ids), completeHandler);
  } catch (error) {
    this.log_.add('ERROR (' + functionId + '): ' + error);
    opt_callback && opt_callback(false);
    return;
  }
};


/**
 * Add new layer(s) with the given id(s).  The callback will notify the user
 * when loading of the layer(s) is complete.  Duplicate layers are not allowed.
 * @param {!Array.<string>|string} ids The id or set of ids to be added.
 * @param {function(boolean)=} opt_callback The callback is called when the
 *     process terminates and passes along a boolean value of whether or not
 *     the process completed.
 */
shellExtension.LayerManager.prototype.add = function(ids, opt_callback) {
  var action = (function(manager) {
    return function(ids, completeHandler) {
      // Create new layer objects and initialize them.
      var layers = [];
      for (var i = 0, len = ids.length; i < len; ++i) {
        var id = ids[i];
        var layer = shellExtension.Layer.create(id, manager.log_);
        manager.layers_[id] = layer;
        manager.layersInOrder_.push(layer);
        layers.push(layer);
      }

      // Retrieve data and load the layers.
      shellExtension.reload.layers(layers, {}, completeHandler, manager.log_);
    };
  })(this);

  var name = 'shellExtension.LayerManager.add';
  var filter = shellExtension.LayerManager.Filter_;
  this.action_(ids, name, filter.NEW, action, opt_callback);
};


/**
 * Changes the layer(s) ondemand flag to the given, valid state.  This function
 * is asynchronous because the change in setting could cause scripts to be
 * loaded immediately.
 * @param {string|!Array.<string>} ids The id(s) of the layer(s) to change.
 * @param {shellExtension.manifest.layer.State} state The new state.
 * @param {function(boolean)=} opt_callback The callback is called when the
 *     process terminates and passes along a boolean value of whether or not
 *     the entire process completed.
 */
shellExtension.LayerManager.prototype.changeOndemand = function(ids, state,
                                                                opt_callback) {
  var action = (function(manager) {
    return function(ids, completeHandler) {
      if (!shellExtension.manifest.layer.validateState(state)) {
        throw 'Invalid state (' + state + ').';
      }

      // Change the ondemand state for each layer and add it to a list if it is
      // not loaded.
      var layers = [];
      for (var i = 0, len = ids.length; i < len; ++i) {
        var layer = manager.layers_[ids[i]];

        layer.setOndemand(state, manager.log_);

        // Check to see if ondemand is off (load immediately) and the layer is
        // not loaded.  If not loaded then queue it for loading.
        if (state == shellExtension.manifest.layer.State.OFF &&
            layer.getDataStatus() != shellExtension.status.Value.LOADED) {
          layers.push(layer);
        }
      }

      shellExtension.reload.scripts(layers, completeHandler, manager.log_);
    };
  })(this);

  var name = 'shellExtension.LayerManager.changeOndemand';
  var filter = shellExtension.LayerManager.Filter_;
  this.action_(ids, name, filter.MANAGED, action, opt_callback);
};


/**
 * Changes the layer(s) state to the given state.  This will only be
 * successful if the state is valid.
 * @param {string|!Array.<string>} ids The id(s) of the layer(s) to change.
 * @param {shellExtension.manifest.layer.State} state The new state.
 * @param {function(boolean)=} opt_callback The callback is called when the
 *     process terminates and passes along a boolean value of whether or not
 *     the entire process completed.
 */
shellExtension.LayerManager.prototype.changeState = function(ids, state,
                                                             opt_callback) {
  var action = (function(manager) {
    return function(ids, completeHandler) {
      if (!shellExtension.manifest.layer.validateState(state)) {
        throw 'Invalid state (' + state + ').';
      }

      var layers = [];
      for (var i = 0, len = ids.length; i < len; ++i) {
        var layer = manager.layers_[ids[i]];
        layer.setState(state);
        layers.push(layer);
      }

      shellExtension.reload.scripts(layers, completeHandler, manager.log_);
    };
  })(this);

  var name = 'shellExtension.LayerManager.changeState';
  var filter = shellExtension.LayerManager.Filter_;
  this.action_(ids, name, filter.MANAGED, action, opt_callback);
};


/**
 * Clears all layer data and leaves the manager in the READY state.  The log
 * and injectionComplete signal are not modified and users do not have to
 * listen to them again.  Also, all user settings and additions will be lost.
 * @return {boolean} Whether or not the clear took place.  It can fail if the
 *     manager is locked.
 */
shellExtension.LayerManager.prototype.clear = function() {
  if (this.status_ != shellExtension.status.Value.READY) {
    return false;
  }

  this.layers_ = {};
  this.layersInOrder_ = [];
  this.status_ = shellExtension.status.Value.READY;
  goog.global.localStorage.removeItem(shellExtension.LayerManager.CACHE_KEY_);
  this.log_.add('CLEARED');
  return true;
};


/**
 * Examines all layers and returns an array of objects where each object
 * contains details about the respective layer.  The layer load order is
 * represented in the order of the returned list.
 * @param {string=} opt_id An optional id if only one result is desired.
 * @return {!Array.<!shellExtension.data.LayerDetails>} An array of layer
 *     details.
 */
shellExtension.LayerManager.prototype.getLayerDetails = function(opt_id) {
  var baseMsg = 'ERROR (shellExtension.LayerManager.getLayerDetails): ';

  try {
    if (this.status_ != shellExtension.status.Value.READY) {
      throw 'Layer manager is not ready.';
    } else if (opt_id && !shellExtension.utils.isType(opt_id, 'string')) {
      throw 'Invalid optional id (' + opt_id + ').';
    }
  } catch (error) {
    this.log_.add(baseMsg + error);
    return [];
  }

  // Provide details for all ids or if given the specified layer.
  var layers = this.layersInOrder_.filter(function(layer) {
    return !opt_id || opt_id == layer.getId();
  });

  // Create an ordered list of array details.
  var layerDetails = layers.map(function(layer) {
    return shellExtension.data.createLayerDetails(layer.getId(),
                                                  layer.getDataStatus(),
                                                  layer.getOndemand(),
                                                  layer.getState(),
                                                  layer.getStatus());
  });

  return layerDetails;
};


/**
 * Retrieve the status of the layer manager.
 * @return {shellExtension.status.Value} The status.
 */
shellExtension.LayerManager.prototype.getStatus = function() {
  return this.status_;
};


/**
 * This callback handles the event when a tab updates.  Injection will occur
 * regardless of whether or not the manager is ready.  If it is not ready then
 * the current snapshot of the manager is used for injection.
 * @param {number} tabId The id of the tab.
 * @param {Object} changeInfo Lists the changes to the state of the tab.
 * @param {Tab} tab The tab.
 * @private
 */
shellExtension.LayerManager.prototype.handleTabUpdated_ = function(tabId,
                                                                   changeInfo,
                                                                   tab) {
  var url = changeInfo['url'] || tab.url;

  // Values for changeInfo['status'] are 'loading' or 'complete'.
  changeInfo['status'] == 'complete' && this.inject(null, tabId, url);
};


/**
 * Allows users to manually inject content scripts into the given tab.
 * @param {Array.<string>|string|null} ids The id(s) of the layers to inject
 *     or null for all layers.
 * @param {number} tabId The id of the tab to insert into.
 * @param {string} url The tab's url to compare against in order to determine
 *     if that content script should be injected into that page.
 * @param {function(boolean)=} opt_callback The callback is called when the
 *     process terminates and passes along a boolean value of whether or not
 *     the entire process completed.
 */
shellExtension.LayerManager.prototype.inject = function(ids, tabId, url,
                                                        opt_callback) {
  // If ids is null then set it to the order set of all managed layers.
  var getId = function(layer) {
    return layer.getId();
  };
  ids = ids || this.layersInOrder_.map(getId);

  var action = (function(manager) {
    return function(ids, completeHandler) {
      // Validate external input.
      if (!shellExtension.utils.isType(tabId, 'number')) {
        throw 'Bad tabId (' + tabId + ').';
      } else if (!shellExtension.utils.isType(url, 'string')) {
        throw 'Bad url (' + url + ').';
      }

      // Create a set of layers whose ids' are in ids.
      var layers = ids.map(function(id) { return manager.layers_[id]; });

      // Create input values used while loading scripts.
      var data = {
        injectBackgroundScript: false,
        loadBackgroundScript: false,
        tabId: tabId,
        url: url
      };

      shellExtension.reload.scripts(layers, completeHandler, manager.log_,
                                    data);
    };
  })(this);

  var onComplete = (function(manager) {
    return function() {
      manager.injectionSignal_.fire();
    };
  })(this);

  var name = 'shellExtension.LayerManager.inject';
  var filter = shellExtension.LayerManager.Filter_;
  this.action_(ids, name, filter.MANAGED, action, opt_callback, onComplete);
};


/**
 * Returns whether or not the layer manager has cached settings.
 * @return {boolean} Is cached?
 */
shellExtension.LayerManager.prototype.isCached = function() {
  return shellExtension.LayerManager.CACHE_KEY_ in goog.global.localStorage;
};


/**
 * If layers with the given ids are being managed, remove them.
 * @param {string|!Array.<string>} ids The id(s) of the layer(s) to remove.
 * @return {boolean} Whether or not the process completed.
 */
shellExtension.LayerManager.prototype.remove = function(ids) {
  var baseMsg = 'ERROR (shellExtension.LayerManager.remove): ';

  try {
    ids = this.setup_(ids, shellExtension.LayerManager.Filter_.MANAGED);
  } catch (error) {
    this.log_.add(baseMsg + error);
    return false;
  }

  this.log_.add('START (shellExtension.LayerManager.remove).');

  // Remove layer with the given id from the set of managed layers.
  var myLayers = this.layers_;
  ids.map(function(id) { delete myLayers[id]; });

  // Create a new ordered list of layers whose ids are not in layerIds.
  this.layersInOrder_ = this.layersInOrder_.filter(function(layer) {
    return !(layer.getId() in ids);
  });

  this.log_.add('COMPLETE (shellExtension.LayerManager.remove).');
  this.tearDown_();

  return true;
};


/**
 * Loads the cached version of the layer state and reset the manager using
 * those values.
 * @param {function(boolean)=} opt_callback The callback is called when the
 *     process terminates and passes along a boolean value of whether or not
 *     the entire process completed.
 */
shellExtension.LayerManager.prototype.resetFromCache = function(opt_callback) {
  // Clear will return false if the manager is not ready.
  if (!this.clear()) {
    try {
      var isType = shellExtension.utils.isType;
      if (opt_callback && !isType(opt_callback, 'function')) {
        throw 'Invalid callback.';
      }

      throw 'Layer manager is not ready.';
   } catch (error) {
      var msgBase = 'ERROR (shellExtension.LayerManager.resetFromCache): ';
      this.log_.add(msgBase + error);
      opt_callback && opt_callback(false);
      return;
    }
  }

  // Extract layer settings from the cache or an empty array if not cached.
  // Return if there is nothing to do.
  var storage = goog.global.localStorage;
  var data = storage.getItem(shellExtension.LayerManager.CACHE_KEY_) || '[]';
  var layerData = /** @type {!Array.<shellExtension.data.LayerSettings>} */
      (JSON.parse(/** @type {string} */ (data)));
  if (!layerData.length) {
    opt_callback && opt_callback(true);
    return;
  }

  // Create an ordered list of layer ids.
  var ids = layerData.map(function(obj) { return obj['id']; });

  var action = (function(manager) {
    return function(ids, completeHandler) {
      // Create new initialized objects and user data for each layer.
      var userData = {};
      var layers = [];
      for (var i = 0, len = layerData.length; i < len; ++i) {
        var id = layerData[i]['id'];
        var layer = shellExtension.Layer.create(id, manager.log_);

        manager.layers_[id] = layer;
        manager.layersInOrder_.push(layer);
        layers.push(layer);

        userData[id] = /** @type {shellExtension.reload.OverrideSettings} */ ({
          ondemand: layerData[i]['ondemand'],
          state: layerData[i]['state']
        });
      }

      // Retrieve data and load the layers.
      shellExtension.reload.layers(layers, userData, completeHandler,
                                   manager.log_);
    };
  })(this);

  var name = 'shellExtension.LayerManager.resetFromCache';
  var filter = shellExtension.LayerManager.Filter_;
  this.action_(ids, name, filter.NONE, action, opt_callback);
};


/**
 * Causes the given layer(s) to be reset to their initial state.
 * @param {Array.<string>|string|null} ids The id(s) of layer(s) to reset.  If
 *     null is given then all layers are reset.
 * @param {function(boolean)=} opt_callback The callback is called when the
 *     process terminates and passes along a boolean value of whether or not
 *     the entire process completed.
 */
shellExtension.LayerManager.prototype.resetLayer = function(ids,
                                                            opt_callback) {
  // If ids is null then set it to the order set of all managed layers.
  var getId = function(layer) {
    return layer.getId();
  };
  ids = ids || this.layersInOrder_.map(getId);

  var action = (function(manager) {
    return function(ids, completeHandler) {
      // Create the list of layers to reset and their associated user settings.
      var userData = {};
      var layers = ids.map(function(id) {
        var layer = manager.layers_[id];
        userData[id] =
            {ondemand: layer.getOndemand(), state: layer.getState()};
        return layer;
      });

      shellExtension.reload.layers(layers, userData, completeHandler,
                                   manager.log_);
    };
  })(this);

  var name = 'shellExtension.LayerManager.resetLayer';
  var filter = shellExtension.LayerManager.Filter_;
  this.action_(ids, name, filter.MANAGED, action, opt_callback);
};


/**
 * Puts the manager into a LOADING state that prevents running of other
 * functions that alter state to prevent conflicts.  The setup routine checks
 * the current status, adds start information to the log, and converts the input
 * id(s) into an array. The new array of ids is filtered. If an error occurs
 * then an exception will be thrown with an error message.
 * @param {!Array.<string>|string} ids The id or set of ids to be validated
 *     and converted into an array of strings.
 * @param {shellExtension.LayerManager.Filter_} filterType The type of filter to
 *     apply to the array of ids.
 * @return {!Array.<string>} The ids converted into an array.
 * @private
 */
shellExtension.LayerManager.prototype.setup_ = function(ids, filterType) {
  if (this.status_ != shellExtension.status.Value.READY) {
    throw 'Layer manager is not ready.';
  }

  // Process id inputs.
  var tempIds = shellExtension.utils.toStringArray(ids);
  if (!tempIds) {
    throw 'Bad ids given as inputs - ' + ids;
  }
  ids = tempIds;

  // Apply any necessary filters to the ids.
  var layers = this.layers_;
  switch (filterType) {
    case shellExtension.LayerManager.Filter_.ALL:
    case shellExtension.LayerManager.Filter_.NONE:
      break;

    case shellExtension.LayerManager.Filter_.MANAGED:
      ids = ids.filter(function(id) { return id in layers; });
      break;

    case shellExtension.LayerManager.Filter_.NEW:
      ids = ids.filter(function(id) { return !(id in layers); });
      break;

    default:
      throw 'Unknown filter type applied to input ids.';
  }

  this.status_ = shellExtension.status.Value.LOADING;
  this.log_.add('LOADING (shellExtension.LayerManager).');

  return ids;
};


/**
 * Puts the manager back into the READY state and caches layer settings in
 * localStorage.
 * @private
 */
shellExtension.LayerManager.prototype.tearDown_ = function() {
  // Create list of layer settings objects and cache them.
  var createLayerObject = function(layer) {
    var create = shellExtension.data.createLayerSettings;
    return create(layer.getId(), layer.getOndemand(), layer.getState());
  };

  var layers = this.layersInOrder_.map(createLayerObject);
  var data = JSON.stringify(layers);
  goog.global.localStorage.setItem(shellExtension.LayerManager.CACHE_KEY_,
                                   data);

  // Restore manager to READY state.
  this.status_ = shellExtension.status.Value.READY;
  this.log_.add('READY (shellExtension.LayerManager).');
};

